Skip to content

Conversation

@theStack
Copy link
Contributor

While reviewing #28307, I noticed that the test framework's key_to_multisig_script helper (introduced in #23305) is broken for pubkey count (n) and threshold (k) values larger than 16. This is due to the implementation currently enforcing a direct single-byte data push (using CScriptOp.encode_op_n), which obviously fails for values 17+. Fix that by passing the numbers directly to the CScript list, where it's automatically converted to minimally-encoded pushes (see class method CScript.__coerce_instance, branch isinstance(other, int)).

The second commit adds a unit test to ensure that the encoding is correct.

@DrahtBot
Copy link
Contributor

DrahtBot commented Aug 22, 2023

The following sections might be updated with supplementary metadata relevant to reviewers and maintainers.

Code Coverage

For detailed information about the code coverage, see the test coverage report.

Reviews

See the guideline for information on the review process.

Type Reviewers
ACK tdb3, rkrux, achow101

If your review is incorrectly listed, please react with 👎 to this comment and the bot will ignore it on the next update.

Conflicts

Reviewers, this pull request conflicts with the following ones:

  • #28710 (Remove the legacy wallet and BDB dependency by achow101)

If you consider this pull request important, please also help to review the conflicting pull requests. Ideally, start with the one that should be merged first.

@DrahtBot DrahtBot added the Tests label Aug 22, 2023
@theStack theStack force-pushed the test-fix_keys_to_multisig_for_17+ branch 2 times, most recently from 73c2def to 85c01bd Compare August 22, 2023 09:57
@luke-jr
Copy link
Member

luke-jr commented Aug 23, 2023

Maybe add a functional test that would have failed too?

@theStack
Copy link
Contributor Author

Maybe add a functional test that would have failed too?

Yeah, seems like a good idea. Added one in rpc_createmultisig which checks that the multisig script encoding (returned as 'redeemScript') is as expected for all possible n (1..20), only using n-of-n for now to keep it simple.

Comment on lines +136 to +142
self.assertEqual(len(normal_ms_script), 1 + 16*34 + 1 + 1)
self.assertTrue(normal_ms_script.startswith(bytes([OP_15])))
self.assertTrue(normal_ms_script.endswith(bytes([OP_16, OP_CHECKMULTISIG])))

# check correct encoding of P2MS script with n,k > 16
max_ms_script = keys_to_multisig_script([fake_pubkey]*20, k=19)
self.assertEqual(len(max_ms_script), 2 + 20*34 + 2 + 1)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we add a comment explaining the logic behind the math in these script length assert calls?
Or maybe refer an existing doc?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trying to understand why is there this else block in coerce_instance here that encodes using pushdata.
@theStack Do you know the reason by chance?
https://github.com/bitcoin/bitcoin/blob/master/test/functional/test_framework/script.py#L451-L452

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rkrux Numbers between -1 and 16 inclusive can use the 1-byte OP_n opcodes. Larger numbers require a script push (which is 2 bytes for numbers up to 127).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: In case this file is touched, it might be clearer (as rkrux mentioned) to add a comment explicitly explaining the math, although this is just a nice-to-have since the addition breaks it out such that the reader is provided a hint (typical multisig script).
Author's preference.

diff --git a/test/functional/test_framework/script_util.py b/test/functional/test_framework/script_util.py
index 855f3b8cf5..69be490a67 100755
--- a/test/functional/test_framework/script_util.py
+++ b/test/functional/test_framework/script_util.py
@@ -133,7 +133,7 @@ class TestFrameworkScriptUtil(unittest.TestCase):
         fake_pubkey = bytes([0]*33)
         # check correct encoding of P2MS script with n,k <= 16
         normal_ms_script = keys_to_multisig_script([fake_pubkey]*16, k=15)
-        self.assertEqual(len(normal_ms_script), 1 + 16*34 + 1 + 1)
+        self.assertEqual(len(normal_ms_script), 1 + 16*34 + 1 + 1) # OP_k + 16*[OP_PUSHBYTES_33 + 33 byte compressed pubkey] + OP_n + OP_CHECKMULTISIG
         self.assertTrue(normal_ms_script.startswith(bytes([OP_15])))
         self.assertTrue(normal_ms_script.endswith(bytes([OP_16, OP_CHECKMULTISIG])))
 

Comment on lines +122 to +74
self.log.info('Check correct encoding of multisig script for all n (1..20)')
for nkeys in range(1, 20+1):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Checking for integers between 15 and 17 would also suffice, right?

Comment on lines +126 to +79
# note that the 'legacy' address type fails for n values larger than 15
# due to exceeding the P2SH size limit (520 bytes), so we use 'bech32' instead
# (for the purpose of this encoding test, we don't care about the resulting address)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for adding the comment, helpful!

Copy link
Contributor

@rkrux rkrux left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Concept ACK d113709

Testing Methodology

  1. Checked out PR
  2. Built core
  3. Ran make check
  4. Ran test/functional/test_runner.py

self.log.info('Check correct encoding of multisig script for all n (1..20)')
for nkeys in range(1, 20+1):
keys = [self.pub[0]]*nkeys
expected_ms_script = keys_to_multisig_script(keys, k=nkeys) # simply use n-of-n
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Curious: Is it common to test the correctness of a test utility function against the response from the bitcoin node?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question. I'm not sure if it's really that common, but there was a wish to also test the utility function via a functional test (see comment #28312 (comment)) and it seemed to be a worthwhile idea to me.

Copy link
Contributor

@tdb3 tdb3 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great work catching the bug and making testing more robust.

ACK for d113709.

One inline recommendation to cover an additional related edge case.

# (for the purpose of this encoding test, we don't care about the resulting address)
res = self.nodes[0].createmultisig(nrequired=nkeys, keys=keys, address_type='bech32')
assert_equal(res['redeemScript'], expected_ms_script.hex())

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since > 20 keys are not allowed when creating a multisig address, recommend adding a test case to check this.

Something like:

diff --git a/test/functional/rpc_createmultisig.py b/test/functional/rpc_createmultisig.py
index 4bbd2a526d..88f4e44a65 100755
--- a/test/functional/rpc_createmultisig.py
+++ b/test/functional/rpc_createmultisig.py
@@ -129,6 +129,9 @@ class RpcCreateMultiSigTest(BitcoinTestFramework):
             res = self.nodes[0].createmultisig(nrequired=nkeys, keys=keys, address_type='bech32')
             assert_equal(res['redeemScript'], expected_ms_script.hex())
 
+        self.log.info('Check that number of keys greater than 20 is not allowed')
+        assert_raises_rpc_error(-8, "Number of keys involved in the multisignature address creation > 20", self.nodes[0].createmultisig, 20, [self.pub[0]]*21, "bech32")
+
     def check_addmultisigaddress_errors(self):
         if self.options.descriptors:
             return

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Such a test case was already introduced in the recently merged PR #28307 (commit 9be6065). Note that this PR is primarily focused on the keys_to_multisig_script helper in the test framework and only uses the createmultisig RPC to verify it.

@DrahtBot DrahtBot requested a review from rkrux April 9, 2024 01:11
@bitcoin bitcoin deleted a comment from Ashflower13 Apr 9, 2024
theStack added 3 commits June 5, 2024 16:15
The helper assumes that the n and k values have to be provided as a
single byte push operation, which is only possible for values up to 16.
Fix that by passing the numbers directly to the CScript list, where it's
automatically converted to minimally-encoded pushes (see class
method `CScript.__coerce_instance`, branch `isinstance(other, int)`).

In case of 17..20, this means that the data-pushes are done with two
bytes using OP_PUSH1 (0x01), e.g. for n=20: 0x01,0x14
@theStack theStack force-pushed the test-fix_keys_to_multisig_for_17+ branch from d113709 to 5cf0a1f Compare June 5, 2024 14:21
@theStack
Copy link
Contributor Author

theStack commented Jun 5, 2024

Rebased on master.

Copy link
Contributor

@tdb3 tdb3 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ACK 5cf0a1f
Re-ran functional tests (passed) and induced a failure by hard-coding 1 as the k value.

Comment on lines +136 to +142
self.assertEqual(len(normal_ms_script), 1 + 16*34 + 1 + 1)
self.assertTrue(normal_ms_script.startswith(bytes([OP_15])))
self.assertTrue(normal_ms_script.endswith(bytes([OP_16, OP_CHECKMULTISIG])))

# check correct encoding of P2MS script with n,k > 16
max_ms_script = keys_to_multisig_script([fake_pubkey]*20, k=19)
self.assertEqual(len(max_ms_script), 2 + 20*34 + 2 + 1)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: In case this file is touched, it might be clearer (as rkrux mentioned) to add a comment explicitly explaining the math, although this is just a nice-to-have since the addition breaks it out such that the reader is provided a hint (typical multisig script).
Author's preference.

diff --git a/test/functional/test_framework/script_util.py b/test/functional/test_framework/script_util.py
index 855f3b8cf5..69be490a67 100755
--- a/test/functional/test_framework/script_util.py
+++ b/test/functional/test_framework/script_util.py
@@ -133,7 +133,7 @@ class TestFrameworkScriptUtil(unittest.TestCase):
         fake_pubkey = bytes([0]*33)
         # check correct encoding of P2MS script with n,k <= 16
         normal_ms_script = keys_to_multisig_script([fake_pubkey]*16, k=15)
-        self.assertEqual(len(normal_ms_script), 1 + 16*34 + 1 + 1)
+        self.assertEqual(len(normal_ms_script), 1 + 16*34 + 1 + 1) # OP_k + 16*[OP_PUSHBYTES_33 + 33 byte compressed pubkey] + OP_n + OP_CHECKMULTISIG
         self.assertTrue(normal_ms_script.startswith(bytes([OP_15])))
         self.assertTrue(normal_ms_script.endswith(bytes([OP_16, OP_CHECKMULTISIG])))
 

Copy link
Contributor

@rkrux rkrux left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reACK 5cf0a1f

Re-ran make and all functional tests, both are successful.
Thanks @theStack for catching this.

@achow101
Copy link
Member

ACK 5cf0a1f

@achow101 achow101 merged commit 4bcef32 into bitcoin:master Jun 17, 2024
@theStack theStack deleted the test-fix_keys_to_multisig_for_17+ branch July 15, 2024 19:24
@bitcoin bitcoin locked and limited conversation to collaborators Jul 15, 2025
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants