0
0
Fork 0
mirror of https://github.com/bitcoin/bitcoin.git synced 2025-03-04 13:55:23 -05:00

tests: add functional test for miniscript decaying multisig

This is similar in structure to test/functional/wallet_multisig_descriptor_psbt.py
both in code and concept. It should serve as some integration testing for
Miniscript descriptors, and also documents a simple multisig that starts as 4-of-4
and decays to 3-of-4, 2-of-4, and finally 1-of-4 at block heights (I think in the
real world aligning this to halvenings would be nice).
This commit is contained in:
Michael Dietz 2025-01-22 10:12:29 -06:00
parent 523520f827
commit bb633c9407
3 changed files with 149 additions and 0 deletions

View file

@ -27,6 +27,8 @@ Supporting RPCs are:
by `scanblocks`) and returns rich event data related to spends or receives associated
with the given descriptors.
Bitcoin Core v24 extended `wsh()` output descriptor with [Miniscript](https://bitcoin.sipa.be/miniscript/) support (initially watch-only). Signing support for Miniscript descriptors was added in v25. And since v26 Miniscript expressions can now be used in Taproot descriptors.
This document describes the language. For the specifics on usage, see the RPC
documentation for the functions mentioned above.
@ -45,6 +47,7 @@ Output descriptors currently support:
- Any type of supported address through the `addr` function.
- Raw hex scripts through the `raw` function.
- Public keys (compressed and uncompressed) in hex notation, or BIP32 extended pubkeys with derivation paths.
- [Miniscript](https://bitcoin.sipa.be/miniscript/) expressions in `wsh` (P2WSH) and `tr` (P2TR) functions.
## Examples
@ -67,6 +70,7 @@ Output descriptors currently support:
- `tr(c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5,{pk(fff97bd5755eeea420453a14355235d382f6472f8568a18b2f057a1460297556),pk(e493dbf1c10d80f3581e4904930b1404cc6c13900ee0758474fa94abe8c4cd13)})` describes a P2TR output with the `c6...` x-only pubkey as internal key, and two script paths.
- `tr(c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5,sortedmulti_a(2,2f8bde4d1a07209355b4a7250a5c5128e88b84bddc619ab7cba8d569b240efe4,5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc))` describes a P2TR output with the `c6...` x-only pubkey as internal key, and a single `multi_a` script that needs 2 signatures with 2 specified x-only keys, which will be sorted lexicographically.
- `wsh(sortedmulti(2,[6f53d49c/44h/1h/0h]tpubDDjsCRDQ9YzyaAq9rspCfq8RZFrWoBpYnLxK6sS2hS2yukqSczgcYiur8Scx4Hd5AZatxTuzMtJQJhchufv1FRFanLqUP7JHwusSSpfcEp2/0/*,[e6807791/44h/1h/0h]tpubDDAfvogaaAxaFJ6c15ht7Tq6ZmiqFYfrSmZsHu7tHXBgnjMZSHAeHSwhvjARNA6Qybon4ksPksjRbPDVp7yXA1KjTjSd5x18KHqbppnXP1s/0/*,[367c9cfa/44h/1h/0h]tpubDDtPnSgWYk8dDnaDwnof4ehcnjuL5VoUt1eW2MoAed1grPHuXPDnkX1fWMvXfcz3NqFxPbhqNZ3QBdYjLz2hABeM9Z2oqMR1Gt2HHYDoCgh/0/*))#av0kxgw0` describes a *2-of-3* multisig. For brevity, the internal "change" descriptor accompanying the above external "receiving" descriptor is not included here, but it typically differs only in the xpub derivation steps, ending in `/1/*` for change addresses.
- `wsh(thresh(4,pk([7258e4f9/44h/1h/0h]tpubDCZrkQoEU3845aFKUu9VQBYWZtrTwxMzcxnBwKFCYXHD6gEXvtFcxddCCLFsEwmxQaG15izcHxj48SXg1QS5FQGMBx5Ak6deXKPAL7wauBU/0/*),s:pk([c80b1469/44h/1h/0h]tpubDD3UwwHoNUF4F3Vi5PiUVTc3ji1uThuRfFyBexTSHoAcHuWW2z8qEE2YujegcLtgthr3wMp3ZauvNG9eT9xfJyxXCfNty8h6rDBYU8UU1qq/0/*),s:pk([4e5024fe/44h/1h/0h]tpubDDLrpPymPLSCJyCMLQdmcWxrAWwsqqssm5NdxT2WSdEBPSXNXxwbeKtsHAyXPpLkhUyKovtZgCi47QxVpw9iVkg95UUgeevyAqtJ9dqBqa1/0/*),s:pk([3b1d1ee9/44h/1h/0h]tpubDCmDTANBWPzf6d8Ap1J5Ku7J1Ay92MpHMrEV7M5muWxCrTBN1g5f1NPcjMEL6dJHxbvEKNZtYCdowaSTN81DAyLsmv6w6xjJHCQNkxrsrfu/0/*),sln:after(840000),sln:after(1050000),sln:after(1260000)))#k28080kv` describes a Miniscript multisig with spending policy: `thresh(4,pk(key_1),pk(key_2),pk(key_3),pk(key_4),after(t1),after(t2),after(t3))` that starts as 4-of-4 and "decays" to 3-of-4, 2-of-4, and finally 1-of-4 at each future halvening block height. For brevity, the internal "change" descriptor accompanying the above external "receiving" descriptor is not included here, but it typically differs only in the xpub derivation steps, ending in `/1/*` for change addresses.
## Reference
@ -195,6 +199,14 @@ preferable in cases where there are more signers. This signing flow is also incl
[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.
#### Basic Miniscript-enabled "decaying" multisig example
For an example of a multisig that starts as 4-of-4 and "decays" to 3-of-4, 2-of-4, and finally 1-of-4 at each future halvening block height, see [this functional test](/test/functional/wallet_miniscript_decaying_multisig_descriptor_psbt.py).
This has the same "architecture" and signing flow as the above [Basic multisig example](#basic-multisig-example). The basic steps are identical aside from the descriptor that defines this wallet, which is of the form: `wsh(thresh(4,pk(XPUB1),s:pk(XPUB2),s:pk(XPUB3),s:pk(XPUB4),sln:after(t1),sln:after(t2),sln:after(t3)))`.
[The test](/test/functional/wallet_miniscript_decaying_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

View file

@ -275,6 +275,7 @@ BASE_SCRIPTS = [
'mempool_truc.py',
'wallet_txn_doublespend.py --legacy-wallet',
'wallet_multisig_descriptor_psbt.py --descriptors',
'wallet_miniscript_decaying_multisig_descriptor_psbt.py --descriptors',
'wallet_txn_doublespend.py --descriptors',
'wallet_backwards_compatibility.py --legacy-wallet',
'wallet_backwards_compatibility.py --descriptors',

View file

@ -0,0 +1,136 @@
#!/usr/bin/env python3
# Copyright (c) 2024 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 miniscript multisig that starts as 4-of-4 and "decays" to 3-of-4, 2-of-4, and finally 1-of-4 at each future halvening block height.
Spending policy: `thresh(4,pk(key_1),pk(key_2),pk(key_3),pk(key_4),after(t1),after(t2),after(t3))`
This is similar to `test/functional/wallet_multisig_descriptor_psbt.py`.
"""
import random
from test_framework.test_framework import BitcoinTestFramework
from test_framework.util import (
assert_approx,
assert_equal,
assert_raises_rpc_error,
)
class WalletMiniscriptDecayingMultisigDescriptorPSBTTest(BitcoinTestFramework):
def add_options(self, parser):
self.add_wallet_options(parser, legacy=False)
def set_test_params(self):
self.num_nodes = 1
self.setup_clean_chain = True
self.wallet_names = []
self.extra_args = [["-keypool=100"]]
def skip_test_if_missing_module(self):
self.skip_if_no_wallet()
self.skip_if_no_sqlite()
@staticmethod
def _get_xpub(wallet, internal):
"""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)."""
pkh_descriptor = next(filter(lambda d: d["desc"].startswith("pkh(") and d["internal"] == internal, wallet.listdescriptors()["descriptors"]))
# keep all key origin information (master key fingerprint and all derivation steps) for proper support of hardware devices
# see section 'Key origin identification' in 'doc/descriptors.md' for more details...
return pkh_descriptor["desc"].split("pkh(")[1].split(")")[0]
def create_multisig(self, external_xpubs, internal_xpubs):
"""The multisig is created by importing the following descriptors. The resulting wallet is watch-only and every signer can do this."""
self.node.createwallet(wallet_name=f"{self.name}", blank=True, descriptors=True, disable_private_keys=True)
multisig = self.node.get_wallet_rpc(f"{self.name}")
# spending policy: `thresh(4,pk(key_1),pk(key_2),pk(key_3),pk(key_4),after(t1),after(t2),after(t3))`
# IMPORTANT: when backing up your descriptor, the order of key_1...key_4 must be correct!
external = multisig.getdescriptorinfo(f"wsh(thresh({self.N},pk({'),s:pk('.join(external_xpubs)}),sln:after({'),sln:after('.join(map(str, self.locktimes))})))")
internal = multisig.getdescriptorinfo(f"wsh(thresh({self.N},pk({'),s:pk('.join(internal_xpubs)}),sln:after({'),sln:after('.join(map(str, self.locktimes))})))")
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)
return multisig
def run_test(self):
self.node = self.nodes[0]
self.M = 4 # starts as 4-of-4
self.N = 4
self.locktimes = [104, 106, 108]
assert_equal(len(self.locktimes), self.N - 1)
self.name = f"{self.M}_of_{self.N}_decaying_multisig"
self.log.info(f"Testing a miniscript multisig which starts as 4-of-4 and 'decays' to 3-of-4 at block height {self.locktimes[0]}, 2-of-4 at {self.locktimes[1]}, and finally 1-of-4 at {self.locktimes[2]}...")
self.log.info("Create the signer wallets and get their xpubs...")
signers = [self.node.get_wallet_rpc(self.node.createwallet(wallet_name=f"signer_{i}", descriptors=True)["name"]) for i in range(self.N)]
external_xpubs, internal_xpubs = [[self._get_xpub(signer, internal) for signer in signers] for internal in [False, True]]
self.log.info("Create the watch-only decaying multisig using signers' xpubs...")
multisig = self.create_multisig(external_xpubs, internal_xpubs)
self.log.info("Get a mature utxo to send to the multisig...")
coordinator_wallet = self.node.get_wallet_rpc(self.node.createwallet(wallet_name="coordinator", descriptors=True)["name"])
self.generatetoaddress(self.node, 101, coordinator_wallet.getnewaddress())
self.log.info("Send funds to the multisig's receiving address...")
deposit_amount = 6.15
coordinator_wallet.sendtoaddress(multisig.getnewaddress(), deposit_amount)
self.generate(self.node, 1)
assert_approx(multisig.getbalance(), deposit_amount, vspan=0.001)
self.log.info("Send transactions from the multisig as required signers decay...")
amount = 1.5
receiver = signers[0]
sent = 0
for locktime in [0] + self.locktimes:
self.log.info(f"At block height >= {locktime} this multisig is {self.M}-of-{self.N}")
current_height = self.node.getblock(self.node.getbestblockhash())['height']
# in this test each signer signs the same psbt "in series" one after the other.
# Another option is for each signer to sign the original psbt, and then combine
# and finalize these. In some cases this may be more optimal for coordination.
psbt = multisig.walletcreatefundedpsbt(inputs=[], outputs={receiver.getnewaddress(): amount}, feeRate=0.00010, locktime=locktime)
# the random sample asserts that any of the signing keys can sign for the 3-of-4,
# 2-of-4, and 1-of-4. While this is basic behavior of the miniscript thresh primitive,
# it is a critical property of this wallet.
for i, m in enumerate(random.sample(range(self.M), self.M)):
psbt = signers[m].walletprocesspsbt(psbt["psbt"])
assert_equal(psbt["complete"], i == self.M - 1)
if self.M < self.N:
self.log.info(f"Check that the time-locked transaction is too immature to spend with {self.M}-of-{self.N} at block height {current_height}...")
assert_equal(current_height >= locktime, False)
assert_raises_rpc_error(-26, "non-final", multisig.sendrawtransaction, psbt["hex"])
self.log.info(f"Generate blocks to reach the time-lock block height {locktime} and broadcast the transaction...")
self.generate(self.node, locktime - current_height)
else:
self.log.info("All the signers are required to spend before the first locktime")
multisig.sendrawtransaction(psbt["hex"])
sent += amount
self.log.info("Check that balances are correct after the transaction has been included in a block...")
self.generate(self.node, 1)
assert_approx(multisig.getbalance(), deposit_amount - sent, vspan=0.001)
assert_equal(receiver.getbalance(), sent)
self.M -= 1 # decay the number of required signers for the next locktime..
if __name__ == "__main__":
WalletMiniscriptDecayingMultisigDescriptorPSBTTest(__file__).main()