This is a follow-up to #29675 and primarily adds test coverage for some of the most prominent failure cases in the last commit.
The following commits address a few left-over nit comments that didn't make it in before merge.
<!--e57a25ab6845829454e8d69fc972939a-->
The following sections might be updated with supplementary metadata relevant to reviewers and maintainers.
<!--006a51241073e994b41acfe9ec718e94-->
For details see: https://corecheck.dev/bitcoin/bitcoin/pulls/33636.
<!--021abf342d371248e50ceaed478a90ca-->
See the guideline for information on the review process.
If your review is incorrectly listed, please copy-paste <code><!--meta-tag:bot-skip--></code> into the comment that the bot should ignore.
<!--5faf32d7da4f0f540f40219e4f7537a3-->
27 | @@ -28,6 +28,178 @@ def set_test_params(self): 28 | def skip_test_if_missing_module(self): 29 | self.skip_if_no_wallet() 30 | 31 | + def setup_musig_scenario(self, num_wallets=3): 32 | + # Miniscript pattern based on number of wallets
In c7c9dc2d8634bc79e76783be113f8d42cee63c6e "test: Add musig failure scenarios"
Miniscript pattern or Multipath pattern?
I guess that might be more informative but the comment is just removed now
197 | + 198 | + dec = self.nodes[0].decodepsbt(comb_nonce_psbt) 199 | + assert "musig2_pubnonces" in dec["inputs"][0] 200 | + assert "musig2_partial_sigs" not in dec["inputs"][0] 201 | + 202 | +
In c7c9dc2d8634bc79e76783be113f8d42cee63c6e "test: Add musig failure scenarios"
Nit: extra blank line
Fixed
198 | + dec = self.nodes[0].decodepsbt(comb_nonce_psbt) 199 | + assert "musig2_pubnonces" in dec["inputs"][0] 200 | + assert "musig2_partial_sigs" not in dec["inputs"][0] 201 | + 202 | + 203 | def do_test(self, comment, pattern, sighash_type=None, scriptpath=False, nosign_wallets=None, only_one_musig_wallet=False):
In https://github.com/bitcoin/bitcoin/commit/c7c9dc2d8634bc79e76783be113f8d42cee63c6e "test: Add musig failure scenarios"
Nit: can rename this function to test_success_case now.
Done
In https://github.com/bitcoin/bitcoin/commit/c7c9dc2d8634bc79e76783be113f8d42cee63c6e "test: Add musig failure scenarios"
I noticed some duplication in both the success and failure cases and I tried to refactor this file based on the following points. Please find full diff below and consider extracting out commonalities.
do_test func so that wallet creation and expectation calculations are not together.create_wallets_and_keys_from_pattern func to be used in both success and failure cases.construct_and_import_musig_descriptor_in_wallets func to be used in both success and failure cases.test_musig_failure_modes func into 4 funcs that have a similar func signature like the do_test func that takes in a comment and pattern to work with. Sort of a nit but I like all the test cases to have a similar format - easier on the eyes of the reader.<details open> <summary>Full diff that works on my system</summary>
diff --git a/test/functional/wallet_musig.py b/test/functional/wallet_musig.py
index 55129b1676..64e1e40259 100755
--- a/test/functional/wallet_musig.py
+++ b/test/functional/wallet_musig.py
@@ -28,43 +28,52 @@ class WalletMuSigTest(BitcoinTestFramework):
def skip_test_if_missing_module(self):
self.skip_if_no_wallet()
- def setup_musig_scenario(self, num_wallets=3):
- # Miniscript pattern based on number of wallets
- placeholders = ",".join(f"${i}/<{i};{i+1}>/*" for i in range(num_wallets))
- pattern = f"rawtr(musig({placeholders}))"
-
+ # Create wallets and extract keys
+ def create_wallets_and_keys_from_pattern(self, pat):
wallets = []
keys = []
- # Create wallets and extract keys
- for i in range(num_wallets):
- wallet_name = f"musig_{self.wallet_num}"
- self.wallet_num += 1
- self.nodes[0].createwallet(wallet_name)
- wallet = self.nodes[0].get_wallet_rpc(wallet_name)
- wallets.append(wallet)
-
- for priv_desc in wallet.listdescriptors(True)["descriptors"]:
- desc = priv_desc["desc"]
- if not desc.startswith("tr("):
- continue
- privkey = PRIVKEY_RE.search(desc).group(1)
- break
- for pub_desc in wallet.listdescriptors()["descriptors"]:
- desc = pub_desc["desc"]
- if not desc.startswith("tr("):
+ for musig in MUSIG_RE.findall(pat):
+ for placeholder in PLACEHOLDER_RE.findall(musig):
+ wallet_index = int(placeholder[1:])
+ if wallet_index < len(wallets):
continue
- pubkey = PUBKEY_RE.search(desc).group(1)
- privkey += ORIGIN_PATH_RE.search(pubkey).group(1)
- break
- keys.append((privkey, pubkey))
- # Construct and import descriptors
+ wallet_name = f"musig_{self.wallet_num}"
+ self.wallet_num += 1
+ self.nodes[0].createwallet(wallet_name)
+ wallet = self.nodes[0].get_wallet_rpc(wallet_name)
+ wallets.append(wallet)
+
+ for priv_desc in wallet.listdescriptors(True)["descriptors"]:
+ desc = priv_desc["desc"]
+ if not desc.startswith("tr("):
+ continue
+ privkey = PRIVKEY_RE.search(desc).group(1)
+ break
+ for pub_desc in wallet.listdescriptors()["descriptors"]:
+ desc = pub_desc["desc"]
+ if not desc.startswith("tr("):
+ continue
+ pubkey = PUBKEY_RE.search(desc).group(1)
+ # Since the pubkey is derived from the private key that we have, we need
+ # to extract and insert the origin path from the pubkey as well.
+ privkey += ORIGIN_PATH_RE.search(pubkey).group(1)
+ break
+ keys.append((privkey, pubkey))
+
+ return wallets, keys
+
+ # Construct and import each wallet's musig descriptor that
+ # contains the private key from that wallet and pubkeys of the others
+ def construct_and_import_musig_descriptor_in_wallets(self, pat, wallets, keys, only_one_musig_wallet=False):
for i, wallet in enumerate(wallets):
- desc = pattern
+ if only_one_musig_wallet and i > 0:
+ continue
+ desc = pat
for j, (priv, pub) in enumerate(keys):
if j == i:
- desc = desc.replace(f"${j}", priv)
+ desc = desc.replace(f"${i}", priv)
else:
desc = desc.replace(f"${j}", pub)
@@ -78,6 +87,10 @@ class WalletMuSigTest(BitcoinTestFramework):
for r in res:
assert_equal(r["success"], True)
+ def setup_musig_scenario(self, pat):
+ wallets, keys = self.create_wallets_and_keys_from_pattern(pat)
+ self.construct_and_import_musig_descriptor_in_wallets(pat, wallets, keys, only_one_musig_wallet=False)
+
# Fund address
addr = wallets[0].getnewaddress(address_type="bech32m")
for wallet in wallets[1:]:
@@ -97,11 +110,9 @@ class WalletMuSigTest(BitcoinTestFramework):
return wallets, psbt
- def test_musig_failure_modes(self):
- """Test various MuSig2 failure scenarios"""
-
- self.log.info("Testing missing participant nonce")
- wallets, psbt = self.setup_musig_scenario(num_wallets=3)
+ def test_failure_case_1(self, comment, pat):
+ self.log.info(f"Testing {comment}")
+ wallets, psbt = self.setup_musig_scenario(pat)
# Only 2 out of 3 participants provide nonces
nonce_psbts = []
@@ -121,8 +132,9 @@ class WalletMuSigTest(BitcoinTestFramework):
# There are still only two nonces
assert_equal(len(dec["inputs"][0].get("musig2_pubnonces", [])), 2)
- self.log.info("Testing insufficient partial signatures")
- wallets, psbt = self.setup_musig_scenario(num_wallets=3)
+ def test_failure_case_2(self, comment, pat):
+ self.log.info(f"Testing {comment}")
+ wallets, psbt = self.setup_musig_scenario(pat)
nonce_psbts = [w.walletprocesspsbt(psbt=psbt)["psbt"] for w in wallets]
comb_nonce_psbt = self.nodes[0].combinepsbt(nonce_psbts)
@@ -142,30 +154,9 @@ class WalletMuSigTest(BitcoinTestFramework):
dec = self.nodes[0].decodepsbt(comb_psig_psbt)
assert_equal(len(dec["inputs"][0]["musig2_partial_sigs"]), 2)
- self.log.info("Testing mismatched key order")
- wallets = []
- keys = []
- for i in range(2):
- wallet_name = f"musig_{self.wallet_num}"
- self.wallet_num += 1
- self.nodes[0].createwallet(wallet_name)
- wallet = self.nodes[0].get_wallet_rpc(wallet_name)
- wallets.append(wallet)
-
- for priv_desc in wallet.listdescriptors(True)["descriptors"]:
- desc = priv_desc["desc"]
- if not desc.startswith("tr("):
- continue
- privkey = PRIVKEY_RE.search(desc).group(1)
- break
- for pub_desc in wallet.listdescriptors()["descriptors"]:
- desc = pub_desc["desc"]
- if not desc.startswith("tr("):
- continue
- pubkey = PUBKEY_RE.search(desc).group(1)
- privkey += ORIGIN_PATH_RE.search(pubkey).group(1)
- break
- keys.append((privkey, pubkey))
+ def test_failure_case_3(self, comment, pat):
+ self.log.info(f"Testing {comment}")
+ wallets, keys = self.create_wallets_and_keys_from_pattern(pat)
desc0 = f"rawtr(musig({keys[0][0]},{keys[1][1]}))"
wallets[0].importdescriptors([{
@@ -187,8 +178,9 @@ class WalletMuSigTest(BitcoinTestFramework):
addr1 = wallets[1].getnewaddress(address_type="bech32m")
assert addr0 != addr1
- self.log.info("Testing finalize without partial sigs")
- wallets, psbt = self.setup_musig_scenario(num_wallets=2)
+ def test_failure_case_4(self, comment, pat):
+ self.log.info(f"Testing {comment}")
+ wallets, psbt = self.setup_musig_scenario(pat)
nonce_psbts = [w.walletprocesspsbt(psbt=psbt)["psbt"] for w in wallets]
comb_nonce_psbt = self.nodes[0].combinepsbt(nonce_psbts)
@@ -199,17 +191,14 @@ class WalletMuSigTest(BitcoinTestFramework):
assert "musig2_pubnonces" in dec["inputs"][0]
assert "musig2_partial_sigs" not in dec["inputs"][0]
-
def do_test(self, comment, pattern, sighash_type=None, scriptpath=False, nosign_wallets=None, only_one_musig_wallet=False):
self.log.info(f"Testing {comment}")
has_internal = MULTIPATH_TWO_RE.search(pattern) is not None
- wallets = []
- keys = []
-
pat = pattern.replace("$H", H_POINT)
+ wallets, keys = self.create_wallets_and_keys_from_pattern(pat)
+ self.construct_and_import_musig_descriptor_in_wallets(pat, wallets, keys, only_one_musig_wallet)
- # Figure out how many wallets are needed and create them
expected_pubnonces = 0
expected_partial_sigs = 0
for musig in MUSIG_RE.findall(pat):
@@ -224,54 +213,9 @@ class WalletMuSigTest(BitcoinTestFramework):
musig_partial_sigs += 1
if wallet_index < len(wallets):
continue
- wallet_name = f"musig_{self.wallet_num}"
- self.wallet_num += 1
- self.nodes[0].createwallet(wallet_name)
- wallet = self.nodes[0].get_wallet_rpc(wallet_name)
- wallets.append(wallet)
-
- for priv_desc in wallet.listdescriptors(True)["descriptors"]:
- desc = priv_desc["desc"]
- if not desc.startswith("tr("):
- continue
- privkey = PRIVKEY_RE.search(desc).group(1)
- break
- for pub_desc in wallet.listdescriptors()["descriptors"]:
- desc = pub_desc["desc"]
- if not desc.startswith("tr("):
- continue
- pubkey = PUBKEY_RE.search(desc).group(1)
- # Since the pubkey is derived from the private key that we have, we need
- # to extract and insert the origin path from the pubkey as well.
- privkey += ORIGIN_PATH_RE.search(pubkey).group(1)
- break
- keys.append((privkey, pubkey))
if musig_partial_sigs is not None:
expected_partial_sigs += musig_partial_sigs
- # Construct and import each wallet's musig descriptor that
- # contains the private key from that wallet and pubkeys of the others
- for i, wallet in enumerate(wallets):
- if only_one_musig_wallet and i > 0:
- continue
- desc = pat
- import_descs = []
- for j, (priv, pub) in enumerate(keys):
- if j == i:
- desc = desc.replace(f"${i}", priv)
- else:
- desc = desc.replace(f"${j}", pub)
-
- import_descs.append({
- "desc": descsum_create(desc),
- "active": True,
- "timestamp": "now",
- })
-
- res = wallet.importdescriptors(import_descs)
- for r in res:
- assert_equal(r["success"], True)
-
# Check that the wallets agree on the same musig address
addr = None
change_addr = None
@@ -393,6 +337,11 @@ class WalletMuSigTest(BitcoinTestFramework):
def run_test(self):
self.def_wallet = self.nodes[0].get_wallet_rpc(self.default_wallet_name)
self.do_test("rawtr(musig(keys/*))", "rawtr(musig($0/<0;1>/*,$1/<1;2>/*,$2/<2;3>/*))")
self.do_test("rawtr(musig(keys/*)) with ALL|ANYONECANPAY", "rawtr(musig($0/<0;1>/*,$1/<1;2>/*,$2/<2;3>/*))", "ALL|ANYONECANPAY")
self.do_test("tr(musig(keys/*)) no multipath", "tr(musig($0/0/*,$1/1/*,$2/2/*))")
@@ -410,8 +359,10 @@ class WalletMuSigTest(BitcoinTestFramework):
self.do_test("tr(musig/*,{pk(partial keys diff musig-1/*),pk(partial keys diff musig-2/*)})} script-path", "tr(musig($0,$1,$2)/<3;4>/*,{pk(musig($0,$1)/<5;6>/*),pk(musig($1,$2)/7/*)})", scriptpath=True, nosign_wallets=[0])
# Run failure mode tests after happy path tests to avoid wallet name conflicts
- self.test_musig_failure_modes()
-
+ self.test_failure_case_1("missing participant nonce", "tr(musig($0/<0;1>/*,$1/<1;2>/*,$2/<2;3>/*))")
+ self.test_failure_case_2("insufficient partial signatures", "rawtr(musig($0/<0;1>/*,$1/<1;2>/*,$2/<2;3>/*))")
+ self.test_failure_case_3("mismatched key order", "tr(musig($0/<0;1>/*,$1/<1;2>/*))")
+ self.test_failure_case_4("finalize without partial sigs", "rawtr(musig($0/<0;1>/*,$1/<1;2>/*))")
if __name__ == '__main__':
WalletMuSigTest(__file__).main()
</details>
Yeah, that looks very good to me, I took this almost exactly as is, squashed the NUM_WALLETS in there because these lines are now touched as well and made you co-author of that commit. Thanks a lot!
408 | @@ -237,6 +409,9 @@ def run_test(self): 409 | self.do_test("tr(musig/*,{pk(partial keys diff musig-1/*),pk(partial keys diff musig-2/*)})}", "tr(musig($0,$1,$2)/<3;4>/*,{pk(musig($0,$1)/<5;6>/*),pk(musig($1,$2)/7/*)})") 410 | self.do_test("tr(musig/*,{pk(partial keys diff musig-1/*),pk(partial keys diff musig-2/*)})} script-path", "tr(musig($0,$1,$2)/<3;4>/*,{pk(musig($0,$1)/<5;6>/*),pk(musig($1,$2)/7/*)})", scriptpath=True, nosign_wallets=[0]) 411 | 412 | + # Run failure mode tests after happy path tests to avoid wallet name conflicts
In https://github.com/bitcoin/bitcoin/commit/c7c9dc2d8634bc79e76783be113f8d42cee63c6e "test: Add musig failure scenarios"
I don't think this comment is necessary(?) because the tests pass even if the failure cases run first
It's gone, probably left-over from an earlier version of the code where this was still the case
Concept ACK 73a5d9315340942f030e6586c640b9240498f8ac
Adds more functional tests that are preferred. I also like the last commit 73a5d9315340942f030e6586c640b9240498f8ac quite a lot, thanks for adding it.
Concept ACK
This is the only place where it is used.
Also improves documentation in the SignMuSig2 function.
Prevent saving another secnonce to the same session id since this might make nonce reuse possible.
181 | + }]) 182 | + 183 | + # Addresses should be different due to different key ordering 184 | + addr0 = wallets[0].getnewaddress(address_type="bech32m") 185 | + addr1 = wallets[1].getnewaddress(address_type="bech32m") 186 | + assert addr0 != addr1
In b7215893e566dec8a085c08e722aa61ec5a2f5a0 "test: Add musig failure scenarios"
The self.test_failure_case_3("mismatched key order", "tr(musig($0/<0;1>/*,$1/<1;2>/*))") case seems incorrect to me because the participant pubkeys are sorted before generating the pubkey (and the address) that should result in both the wallets having same address.
https://github.com/bitcoin/bitcoin/blob/238c1c8933b1f7479a9bca2b7cb207d26151c39d/src/script/descriptor.cpp#L658
This test case is passing because desc0 and desc1 are not successfully imported, and instead an error is thrown from the importdescriptors call which is unchecked. I believe the last address assertion is on the ones generated by the non-musig taproot descriptors of the two wallets, which anyway would be different.
2025-11-25T08:25:08.970028Z TestFramework (INFO): Testing mismatched key order
[{'success': False,
'error': {'code': -8, 'message': 'Active descriptors must be ranged'}}]
Upon successfully importing the descriptors after the below diff, the test fails.
diff --git a/test/functional/wallet_musig.py b/test/functional/wallet_musig.py
index 0323d488b5..9d6513c703 100755
--- a/test/functional/wallet_musig.py
+++ b/test/functional/wallet_musig.py
@@ -158,7 +158,7 @@ class WalletMuSigTest(BitcoinTestFramework):
self.log.info(f"Testing {comment}")
wallets, keys = self.create_wallets_and_keys_from_pattern(pat)
- desc0 = f"rawtr(musig({keys[0][0]},{keys[1][1]}))"
+ desc0 = f"rawtr(musig({keys[0][0]},{keys[1][1]})/*)"
wallets[0].importdescriptors([{
"desc": descsum_create(desc0),
"active": True,
@@ -166,7 +166,7 @@ class WalletMuSigTest(BitcoinTestFramework):
}])
# Reverse order to desc0
- desc1 = f"rawtr(musig({keys[1][0]},{keys[0][1]}))"
+ desc1 = f"rawtr(musig({keys[1][0]},{keys[0][1]})/*)"
wallets[1].importdescriptors([{
"desc": descsum_create(desc1),
"active": True,
2025-11-25T08:36:43.985277Z TestFramework (ERROR): Unexpected exception
Traceback (most recent call last):
File "/Users/rkrux/projects/bitcoin/test/functional/test_framework/test_framework.py", line 142, in main
self.run_test()
~~~~~~~~~~~~~^^
File "/Users/rkrux/projects/bitcoin/./build/test/functional/wallet_musig.py", line 358, in run_test
self.test_failure_case_3("mismatched key order", "tr(musig($0/<0;1>/*,$1/<1;2>/*))")
~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/rkrux/projects/bitcoin/./build/test/functional/wallet_musig.py", line 179, in test_failure_case_3
assert addr0 != addr1
^^^^^^^^^^^^^^
AssertionError
I believe this test case should be removed.
Right, I did have a wrong understanding here. I guess I didn't review the musig descriptors PR and I went by my assumptions, but I have looked at the PR again and at BIP390 and it seems like hitting this error should be impossible. Thanks!
Thanks for accepting the refactoring suggestions!
Also changes the the non-constant variable NUM_WALLETS to lower case and
refactors the success case scenarios to reuse existing code.
Co-authored-by: rkrux <rkrux.connect@gmail.com>
lgtm ACK 217dbbb
Thanks for accepting all the suggestions.
In PR description:
- primarily adds test coverage for some of the most prominent failure cases in the first commit.
+ primarily adds test coverage for some of the most prominent failure cases in the last commit.
In PR description:
- primarily adds test coverage for some of the most prominent failure cases in the first commit. + primarily adds test coverage for some of the most prominent failure cases in the last commit.
Fixed as well
ACK 217dbbbb5e38ab582ee0b3ef37fe9e99d887d7c8