Skip to content

Commit 1f20501

Browse files
committed
test: add functional test for multisig flow with descriptor wallets and PSBTs
1 parent 803ef70 commit 1f20501

File tree

2 files changed

+144
-0
lines changed

2 files changed

+144
-0
lines changed

test/functional/test_runner.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@
204204
'feature_assumevalid.py',
205205
'example_test.py',
206206
'wallet_txn_doublespend.py --legacy-wallet',
207+
'wallet_multisig_descriptor_psbt.py',
207208
'wallet_txn_doublespend.py --descriptors',
208209
'feature_backwards_compatibility.py --legacy-wallet',
209210
'feature_backwards_compatibility.py --descriptors',
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
#!/usr/bin/env python3
2+
# Copyright (c) 2021 The Bitcoin Core developers
3+
# Distributed under the MIT software license, see the accompanying
4+
# file COPYING or http://www.opensource.org/licenses/mit-license.php.
5+
"""Test a basic M-of-N multisig setup between multiple people using descriptor wallets and PSBTs, as well as a signing flow.
6+
7+
This is meant to be documentation as much as functional tests, so it is kept as simple and readable as possible.
8+
"""
9+
10+
from test_framework.address import base58_to_byte
11+
from test_framework.test_framework import BitcoinTestFramework
12+
from test_framework.util import (
13+
assert_approx,
14+
assert_equal,
15+
)
16+
17+
18+
class WalletMultisigDescriptorPSBTTest(BitcoinTestFramework):
19+
def set_test_params(self):
20+
self.num_nodes = 3
21+
self.setup_clean_chain = True
22+
self.wallet_names = []
23+
self.extra_args = [["-keypool=100"]] * self.num_nodes
24+
25+
def skip_test_if_missing_module(self):
26+
self.skip_if_no_wallet()
27+
self.skip_if_no_sqlite()
28+
29+
def _get_xpub(self, wallet):
30+
"""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)."""
31+
descriptor = next(filter(lambda d: d["desc"].startswith("pkh"), wallet.listdescriptors()["descriptors"]))
32+
return descriptor["desc"].split("]")[-1].split("/")[0]
33+
34+
def _check_psbt(self, psbt, to, value, multisig):
35+
"""Helper method for any of the N participants to check the psbt with decodepsbt and verify it is OK before signing."""
36+
tx = multisig.decodepsbt(psbt)["tx"]
37+
amount = 0
38+
for vout in tx["vout"]:
39+
address = vout["scriptPubKey"]["address"]
40+
assert_equal(multisig.getaddressinfo(address)["ischange"], address != to)
41+
if address == to:
42+
amount += vout["value"]
43+
assert_approx(amount, float(value), vspan=0.001)
44+
45+
def generate_and_exchange_xpubs(self, participants):
46+
"""Every participant generates an xpub. The most straightforward way is to create a new descriptor wallet. Avoid reusing this wallet for any other purpose.."""
47+
for i, node in enumerate(participants):
48+
node.createwallet(wallet_name=f"participant_{i}", descriptors=True)
49+
yield self._get_xpub(node.get_wallet_rpc(f"participant_{i}"))
50+
51+
def participants_import_descriptors(self, participants, xpubs):
52+
"""The multisig is created by importing the following descriptors. The resulting wallet is watch-only and every participant can do this."""
53+
# some simple validation
54+
assert_equal(len(xpubs), self.N)
55+
# a sanity-check/assertion, this will throw if the base58 checksum of any of the provided xpubs are invalid
56+
for xpub in xpubs:
57+
base58_to_byte(xpub)
58+
59+
for i, node in enumerate(participants):
60+
node.createwallet(wallet_name=f"{self.name}_{i}", blank=True, descriptors=True, disable_private_keys=True)
61+
multisig = node.get_wallet_rpc(f"{self.name}_{i}")
62+
external = multisig.getdescriptorinfo(f"wsh(sortedmulti({self.M},{f'/{0}/*,'.join(xpubs)}/{0}/*))")
63+
internal = multisig.getdescriptorinfo(f"wsh(sortedmulti({self.M},{f'/{1}/*,'.join(xpubs)}/{1}/*))")
64+
result = multisig.importdescriptors([
65+
{ # receiving addresses (internal: False)
66+
"desc": external["descriptor"],
67+
"active": True,
68+
"internal": False,
69+
"timestamp": "now",
70+
},
71+
{ # change addresses (internal: True)
72+
"desc": internal["descriptor"],
73+
"active": True,
74+
"internal": True,
75+
"timestamp": "now",
76+
},
77+
])
78+
assert all(r["success"] for r in result)
79+
80+
def get_multisig_receiving_address(self):
81+
"""We will send funds to the resulting address (every participant should get the same addresses)."""
82+
multisig = self.nodes[0].get_wallet_rpc(f"{self.name}_{0}")
83+
receiving_address = multisig.getnewaddress()
84+
for i in range(1, self.N):
85+
assert_equal(receiving_address, self.nodes[i].get_wallet_rpc(f"{self.name}_{i}").getnewaddress())
86+
return receiving_address
87+
88+
def make_sending_transaction(self, to, value):
89+
"""Make a sending transaction, created using walletcreatefundedpsbt (anyone can initiate this)."""
90+
return self.nodes[0].get_wallet_rpc(f"{self.name}_{0}").walletcreatefundedpsbt(inputs=[], outputs={to: value}, options={"feeRate": 0.00010})
91+
92+
def run_test(self):
93+
self.M = 2
94+
self.N = self.num_nodes
95+
self.name = f"{self.M}_of_{self.N}_multisig"
96+
self.log.info(f"Testing {self.name}...")
97+
98+
self.log.info("Generate and exchange xpubs...")
99+
xpubs = list(self.generate_and_exchange_xpubs(self.nodes))
100+
101+
self.log.info("Every participant imports the following descriptors to create the watch-only multisig...")
102+
self.participants_import_descriptors(self.nodes, xpubs)
103+
104+
self.log.info("Get a mature utxo to send to the multisig...")
105+
coordinator_wallet = self.nodes[0].get_wallet_rpc(f"participant_{0}")
106+
coordinator_wallet.generatetoaddress(101, coordinator_wallet.getnewaddress())
107+
108+
deposit_amount = 6.15
109+
multisig_receiving_address = self.get_multisig_receiving_address()
110+
self.log.info("Send funds to the resulting multisig receiving address...")
111+
coordinator_wallet.sendtoaddress(multisig_receiving_address, deposit_amount)
112+
self.nodes[0].generate(1)
113+
self.sync_all()
114+
for n in range(self.N):
115+
assert_approx(self.nodes[n].get_wallet_rpc(f"{self.name}_{n}").getbalance(), deposit_amount, vspan=0.001)
116+
117+
self.log.info("Send a transaction from the multisig!")
118+
to = self.nodes[self.N - 1].get_wallet_rpc(f"participant_{self.N - 1}").getnewaddress()
119+
value = 1
120+
psbt = self.make_sending_transaction(to, value)
121+
122+
psbts = []
123+
self.log.info("At least M users check the psbt with decodepsbt and (if OK) signs it with walletprocesspsbt...")
124+
for m in range(self.M):
125+
signers_multisig = self.nodes[m].get_wallet_rpc(f"{self.name}_{m}")
126+
self._check_psbt(psbt["psbt"], to, value, signers_multisig)
127+
signing_wallet = self.nodes[m].get_wallet_rpc(f"participant_{m}")
128+
partially_signed_psbt = signing_wallet.walletprocesspsbt(psbt["psbt"])
129+
psbts.append(partially_signed_psbt["psbt"])
130+
131+
self.log.info("Collect the signed PSBTs with combinepsbt, finalizepsbt, then broadcast the resulting transaction...")
132+
combined = coordinator_wallet.combinepsbt(psbts)
133+
finalized = coordinator_wallet.finalizepsbt(combined)
134+
coordinator_wallet.sendrawtransaction(finalized["hex"])
135+
136+
self.log.info("Check that balances are correct after the transaction has been included in a block.")
137+
self.nodes[0].generate(1)
138+
self.sync_all()
139+
assert_approx(self.nodes[0].get_wallet_rpc(f"{self.name}_{0}").getbalance(), deposit_amount - value, vspan=0.001)
140+
assert_equal(self.nodes[self.N - 1].get_wallet_rpc(f"participant_{self.N - 1}").getbalance(), value)
141+
142+
if __name__ == "__main__":
143+
WalletMultisigDescriptorPSBTTest().main()

0 commit comments

Comments
 (0)