aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorW. J. van der Laan <laanwj@protonmail.com>2021-10-18 16:17:08 +0200
committerW. J. van der Laan <laanwj@protonmail.com>2021-10-18 16:17:45 +0200
commitff65b696f3c6f6e17a790c6646249163ddb39eda (patch)
tree9063e5f42bf23e4883d4e2a4104c496445d59a00
parent2e82af46e237299246b53cb69c101ddba0175838 (diff)
parent9de0d94508828f5fdfaf688ccda5a91d38b32c58 (diff)
downloadbitcoin-ff65b696f3c6f6e17a790c6646249163ddb39eda.tar.xz
Merge bitcoin/bitcoin#22067: Test and document a basic M-of-N multisig using descriptor wallets and PSBTs
9de0d94508828f5fdfaf688ccda5a91d38b32c58 doc: add disclaimer highlighting shortcomings of the basic multisig example (Michael Dietz) f9479e4626f6b5126ff8cdab3a7e718c609429ef test, doc: basic M-of-N multisig minor cleanup and clarifications (Michael Dietz) e05cd0546a155afcd45c43ce730c4abecd40dfed doc: add another signing flow for multisig with descriptor wallets and PSBTs (Michael Dietz) 17dd6573008c8aca9fc0da9419225c85a4f94330 doc: M-of-N multisig using descriptor wallets and PSBTs, as well as a signing flow (Michael Dietz) 1f20501efce041d34e63ab9a11359bedf4a82cd5 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 9de0d94508828f5fdfaf688ccda5a91d38b32c58 Tree-SHA512: 6c76e787c21f09d8be5eaa11f3ca3eaa4868497824050562bdfb2095c73b90f5e8987a8775119891d6bfde586e3f31ad1b13e4b67b0802e1d23ef050227a1211
-rw-r--r--doc/descriptors.md41
-rw-r--r--doc/psbt.md3
-rwxr-xr-xtest/functional/test_runner.py1
-rwxr-xr-xtest/functional/wallet_multisig_descriptor_psbt.py163
4 files changed, 208 insertions, 0 deletions
diff --git a/doc/descriptors.md b/doc/descriptors.md
index 3bbb626a42..57a0f99d70 100644
--- a/doc/descriptors.md
+++ b/doc/descriptors.md
@@ -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
diff --git a/doc/psbt.md b/doc/psbt.md
index c411b31d5d..0f31cb8eba 100644
--- a/doc/psbt.md
+++ b/doc/psbt.md
@@ -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
diff --git a/test/functional/test_runner.py b/test/functional/test_runner.py
index d6f61bfbff..b91b294108 100755
--- a/test/functional/test_runner.py
+++ b/test/functional/test_runner.py
@@ -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',
diff --git a/test/functional/wallet_multisig_descriptor_psbt.py b/test/functional/wallet_multisig_descriptor_psbt.py
new file mode 100755
index 0000000000..68c206b038
--- /dev/null
+++ b/test/functional/wallet_multisig_descriptor_psbt.py
@@ -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()