Merge bitcoin/bitcoin#22067: Test and document a basic M-of-N multisig using descriptor wallets and PSBTs

9de0d94508 doc: add disclaimer highlighting shortcomings of the basic multisig example (Michael Dietz)
f9479e4626 test, doc: basic M-of-N multisig minor cleanup and clarifications (Michael Dietz)
e05cd0546a doc: add another signing flow for multisig with descriptor wallets and PSBTs (Michael Dietz)
17dd657300 doc: M-of-N multisig using descriptor wallets and PSBTs, as well as a signing flow (Michael Dietz)
1f20501efc test: add functional test for multisig flow with descriptor wallets and PSBTs (Michael Dietz)

Pull request description:

  Aims to resolve issue https://github.com/bitcoin/bitcoin/issues/21278. I try to follow the steps laanwj outlined there exactly, with the exception of using `combinepsbt` instead of `joinpsbts`. I wrote a functional test to make sure it works as expected before doing the docs, and figured it would also be a good source of documentation. So I kept the test as simple as possible and didn't go crazy with edge-cases and various checks. I do have a lot more test-cases I've written that I will follow up with (either in a separate PR or another commit - lmk if you have a preference), but I want to do it in a way that doesn't bloat this test so it remains useful as a quickstart (unless that's a bad idea)?

ACKs for top commit:
  S3RK:
    Code review ACK 9de0d94. Rspigler's argument convinced me that we should leave the workflow with two wallets. I assume using multisig with external signers is a popular use-case and it's important to keep compatibility.
  laanwj:
    Code and documentation review ACK 9de0d94508

Tree-SHA512: 6c76e787c21f09d8be5eaa11f3ca3eaa4868497824050562bdfb2095c73b90f5e8987a8775119891d6bfde586e3f31ad1b13e4b67b0802e1d23ef050227a1211
pull/23303/head
W. J. van der Laan 3 years ago
commit ff65b696f3
No known key found for this signature in database
GPG Key ID: 1E4AED62986CD25D

@ -139,6 +139,47 @@ Key order does not matter for `sortedmulti()`. `sortedmulti()` behaves in the sa
as `multi()` does but the keys are reordered in the resulting script such that they
are lexicographically ordered as described in BIP67.
#### Basic multisig example
For a good example of a basic M-of-N multisig between multiple participants using descriptor
wallets and PSBTs, as well as a signing flow, see [this functional test](/test/functional/wallet_multisig_descriptor_psbt.py).
Disclaimers: It is important to note that this example serves as a quick-start and is kept basic for readability. A downside of the approach
outlined here is that each participant must maintain (and backup) two separate wallets: a signer and the corresponding multisig.
It should also be noted that privacy best-practices are not "by default" here - participants should take care to only use the signer to sign
transactions related to the multisig. Lastly, it is not recommended to use anything other than a Bitcoin Core descriptor wallet to serve as your
signer(s). Other wallets, whether hardware or software, likely impose additional checks and safeguards to prevent users from signing transactions that
could lead to loss of funds, or are deemed security hazards. Conforming to various 3rd-party checks and verifications is not in the scope of this example.
The basic steps are:
1. Every participant generates an xpub. The most straightforward way is to create a new descriptor wallet which we will refer to as
the participant's signer wallet. Avoid reusing this wallet for any purpose other than signing transactions from the
corresponding multisig we are about to create. Hint: extract the wallet's xpubs using `listdescriptors` and pick the one from the
`pkh` descriptor since it's least likely to be accidentally reused (legacy addresses)
2. Create a watch-only descriptor wallet (blank, private keys disabled). Now the multisig is created by importing the two descriptors:
`wsh(sortedmulti(<M>,XPUB1/0/*,XPUB2/0/*,…,XPUBN/0/*))` and `wsh(sortedmulti(<M>,XPUB1/1/*,XPUB2/1/*,…,XPUBN/1/*))`
(one descriptor w/ `0` for receiving addresses and another w/ `1` for change). Every participant does this
3. A receiving address is generated for the multisig. As a check to ensure step 2 was done correctly, every participant
should verify they get the same addresses
4. Funds are sent to the resulting address
5. A sending transaction from the multisig is created using `walletcreatefundedpsbt` (anyone can initiate this). It is simple to do
this in the GUI by going to the `Send` tab in the multisig wallet and creating an unsigned transaction (PSBT)
6. At least `M` participants check the PSBT with their multisig using `decodepsbt` to verify the transaction is OK before signing it.
7. (If OK) the participant signs the PSBT with their signer wallet using `walletprocesspsbt`. It is simple to do this in the GUI by
loading the PSBT from file and signing it
8. The signed PSBTs are collected with `combinepsbt`, finalized w/ `finalizepsbt`, and then the resulting transaction is broadcasted
to the network. Note that any wallet (eg one of the signers or multisig) is capable of doing this.
9. Checks that balances are correct after the transaction has been included in a block
You may prefer a daisy chained signing flow where each participant signs the PSBT one after another until
the PSBT has been signed `M` times and is "complete." For the most part, the steps above remain the same, except (6, 7)
change slightly from signing the original PSBT in parallel to signing it in series. `combinepsbt` is not necessary with
this signing flow and the last (`m`th) signer can just broadcast the PSBT after signing. Note that a parallel signing flow may be
preferable in cases where there are more signers. This signing flow is also included in the test / Python example.
[The test](/test/functional/wallet_multisig_descriptor_psbt.py) is meant to be documentation as much as it is a functional test, so
it is kept as simple and readable as possible.
### BIP32 derived keys and chains
Most modern wallet software and hardware uses keys that are derived using

@ -92,6 +92,9 @@ hardware implementations will typically implement multiple roles simultaneously.
#### Multisig with multiple Bitcoin Core instances
For a quick start see [Basic M-of-N multisig example using descriptor wallets and PSBTs](./descriptors.md#basic-multisig-example).
If you are using legacy wallets feel free to continue with the example provided here.
Alice, Bob, and Carol want to create a 2-of-3 multisig address. They're all using
Bitcoin Core. We assume their wallets only contain the multisig funds. In case
they also have a personal wallet, this can be accomplished through the

@ -207,6 +207,7 @@ BASE_SCRIPTS = [
'feature_assumevalid.py',
'example_test.py',
'wallet_txn_doublespend.py --legacy-wallet',
'wallet_multisig_descriptor_psbt.py',
'wallet_txn_doublespend.py --descriptors',
'feature_backwards_compatibility.py --legacy-wallet',
'feature_backwards_compatibility.py --descriptors',

@ -0,0 +1,163 @@
#!/usr/bin/env python3
# Copyright (c) 2021 The Bitcoin Core developers
# Distributed under the MIT software license, see the accompanying
# file COPYING or http://www.opensource.org/licenses/mit-license.php.
"""Test a basic M-of-N multisig setup between multiple people using descriptor wallets and PSBTs, as well as a signing flow.
This is meant to be documentation as much as functional tests, so it is kept as simple and readable as possible.
"""
from test_framework.address import base58_to_byte
from test_framework.test_framework import BitcoinTestFramework
from test_framework.util import (
assert_approx,
assert_equal,
)
class WalletMultisigDescriptorPSBTTest(BitcoinTestFramework):
def set_test_params(self):
self.num_nodes = 3
self.setup_clean_chain = True
self.wallet_names = []
self.extra_args = [["-keypool=100"]] * self.num_nodes
def skip_test_if_missing_module(self):
self.skip_if_no_wallet()
self.skip_if_no_sqlite()
@staticmethod
def _get_xpub(wallet):
"""Extract the wallet's xpubs using `listdescriptors` and pick the one from the `pkh` descriptor since it's least likely to be accidentally reused (legacy addresses)."""
descriptor = next(filter(lambda d: d["desc"].startswith("pkh"), wallet.listdescriptors()["descriptors"]))
return descriptor["desc"].split("]")[-1].split("/")[0]
@staticmethod
def _check_psbt(psbt, to, value, multisig):
"""Helper function for any of the N participants to check the psbt with decodepsbt and verify it is OK before signing."""
tx = multisig.decodepsbt(psbt)["tx"]
amount = 0
for vout in tx["vout"]:
address = vout["scriptPubKey"]["address"]
assert_equal(multisig.getaddressinfo(address)["ischange"], address != to)
if address == to:
amount += vout["value"]
assert_approx(amount, float(value), vspan=0.001)
def participants_create_multisigs(self, xpubs):
"""The multisig is created by importing the following descriptors. The resulting wallet is watch-only and every participant can do this."""
# some simple validation
assert_equal(len(xpubs), self.N)
# a sanity-check/assertion, this will throw if the base58 checksum of any of the provided xpubs are invalid
for xpub in xpubs:
base58_to_byte(xpub)
for i, node in enumerate(self.nodes):
node.createwallet(wallet_name=f"{self.name}_{i}", blank=True, descriptors=True, disable_private_keys=True)
multisig = node.get_wallet_rpc(f"{self.name}_{i}")
external = multisig.getdescriptorinfo(f"wsh(sortedmulti({self.M},{f'/0/*,'.join(xpubs)}/0/*))")
internal = multisig.getdescriptorinfo(f"wsh(sortedmulti({self.M},{f'/1/*,'.join(xpubs)}/1/*))")
result = multisig.importdescriptors([
{ # receiving addresses (internal: False)
"desc": external["descriptor"],
"active": True,
"internal": False,
"timestamp": "now",
},
{ # change addresses (internal: True)
"desc": internal["descriptor"],
"active": True,
"internal": True,
"timestamp": "now",
},
])
assert all(r["success"] for r in result)
yield multisig
def run_test(self):
self.M = 2
self.N = self.num_nodes
self.name = f"{self.M}_of_{self.N}_multisig"
self.log.info(f"Testing {self.name}...")
participants = {
# Every participant generates an xpub. The most straightforward way is to create a new descriptor wallet.
# This wallet will be the participant's `signer` for the resulting multisig. Avoid reusing this wallet for any other purpose (for privacy reasons).
"signers": [node.get_wallet_rpc(node.createwallet(wallet_name=f"participant_{self.nodes.index(node)}", descriptors=True)["name"]) for node in self.nodes],
# After participants generate and exchange their xpubs they will each create their own watch-only multisig.
# Note: these multisigs are all the same, this justs highlights that each participant can independently verify everything on their own node.
"multisigs": []
}
self.log.info("Generate and exchange xpubs...")
xpubs = [self._get_xpub(signer) for signer in participants["signers"]]
self.log.info("Every participant imports the following descriptors to create the watch-only multisig...")
participants["multisigs"] = list(self.participants_create_multisigs(xpubs))
self.log.info("Check that every participant's multisig generates the same addresses...")
for _ in range(10): # we check that the first 10 generated addresses are the same for all participant's multisigs
receive_addresses = [multisig.getnewaddress() for multisig in participants["multisigs"]]
all(address == receive_addresses[0] for address in receive_addresses)
change_addresses = [multisig.getrawchangeaddress() for multisig in participants["multisigs"]]
all(address == change_addresses[0] for address in change_addresses)
self.log.info("Get a mature utxo to send to the multisig...")
coordinator_wallet = participants["signers"][0]
coordinator_wallet.generatetoaddress(101, coordinator_wallet.getnewaddress())
deposit_amount = 6.15
multisig_receiving_address = participants["multisigs"][0].getnewaddress()
self.log.info("Send funds to the resulting multisig receiving address...")
coordinator_wallet.sendtoaddress(multisig_receiving_address, deposit_amount)
self.nodes[0].generate(1)
self.sync_all()
for participant in participants["multisigs"]:
assert_approx(participant.getbalance(), deposit_amount, vspan=0.001)
self.log.info("Send a transaction from the multisig!")
to = participants["signers"][self.N - 1].getnewaddress()
value = 1
self.log.info("First, make a sending transaction, created using `walletcreatefundedpsbt` (anyone can initiate this)...")
psbt = participants["multisigs"][0].walletcreatefundedpsbt(inputs=[], outputs={to: value}, options={"feeRate": 0.00010})
psbts = []
self.log.info("Now at least M users check the psbt with decodepsbt and (if OK) signs it with walletprocesspsbt...")
for m in range(self.M):
signers_multisig = participants["multisigs"][m]
self._check_psbt(psbt["psbt"], to, value, signers_multisig)
signing_wallet = participants["signers"][m]
partially_signed_psbt = signing_wallet.walletprocesspsbt(psbt["psbt"])
psbts.append(partially_signed_psbt["psbt"])
self.log.info("Finally, collect the signed PSBTs with combinepsbt, finalizepsbt, then broadcast the resulting transaction...")
combined = coordinator_wallet.combinepsbt(psbts)
finalized = coordinator_wallet.finalizepsbt(combined)
coordinator_wallet.sendrawtransaction(finalized["hex"])
self.log.info("Check that balances are correct after the transaction has been included in a block.")
self.nodes[0].generate(1)
self.sync_all()
assert_approx(participants["multisigs"][0].getbalance(), deposit_amount - value, vspan=0.001)
assert_equal(participants["signers"][self.N - 1].getbalance(), value)
self.log.info("Send another transaction from the multisig, this time with a daisy chained signing flow (one after another in series)!")
psbt = participants["multisigs"][0].walletcreatefundedpsbt(inputs=[], outputs={to: value}, options={"feeRate": 0.00010})
for m in range(self.M):
signers_multisig = participants["multisigs"][m]
self._check_psbt(psbt["psbt"], to, value, signers_multisig)
signing_wallet = participants["signers"][m]
psbt = signing_wallet.walletprocesspsbt(psbt["psbt"])
assert_equal(psbt["complete"], m == self.M - 1)
finalized = coordinator_wallet.finalizepsbt(psbt["psbt"])
coordinator_wallet.sendrawtransaction(finalized["hex"])
self.log.info("Check that balances are correct after the transaction has been included in a block.")
self.nodes[0].generate(1)
self.sync_all()
assert_approx(participants["multisigs"][0].getbalance(), deposit_amount - (value * 2), vspan=0.001)
assert_equal(participants["signers"][self.N - 1].getbalance(), value * 2)
if __name__ == "__main__":
WalletMultisigDescriptorPSBTTest().main()
Loading…
Cancel
Save